// Based on https://github.com/gera2ld/tarjs
// and https://github.com/ankitrohatgi/tarballjs/blob/master/tarball.js
type TarFileType = string;
// Added the most common codes
export const TarFileTypeCodes = {
	File: "0",
	Dir: "5",
};
const encoder = new TextEncoder();
const utf8Encode = (input: string) => encoder.encode(input);
const decoder = new TextDecoder();
const utf8Decode = (input: Uint8Array) => decoder.decode(input);

export interface ITarFileInfo {
	name: string;
	type: TarFileType;
	size: number;
	mode: number;
	mtime: number;
	user: string;
	group: string;
	headerOffset: number;
}

export interface ITarWriteItem {
	name: string;
	type: TarFileType;
	data: ArrayBuffer | Promise<ArrayBuffer> | null;
	size: number;
	opts?: Partial<ITarWriteOptions>;
}

export interface ITarWriteOptions {
	uid: number;
	gid: number;
	mode: number;
	mtime: number;
	user: string;
	group: string;
}

export class TarReader {
	public fileInfo: ITarFileInfo[] = [];
	private _buffer: ArrayBuffer | null = null;

	constructor() {
		this.reset();
	}

	get buffer() {
		if (!this._buffer) {
			throw new Error("Buffer is not set");
		}

		return this._buffer;
	}

	reset() {
		this.fileInfo = [];
		this._buffer = null;
	}

	async readFile(file: ArrayBuffer | Uint8Array | Blob) {
		this.reset();
		this._buffer = await getArrayBuffer(file);
		this.readFileInfo();
		return this.fileInfo;
	}

	private readFileInfo() {
		this.fileInfo = [];
		let offset = 0;

		while (offset < this.buffer.byteLength - 512) {
			const fileName = this.readFileName(offset);
			if (!fileName) {
				break;
			}
			const fileType = this.readFileType(offset);
			const fileSize = this.readFileSize(offset);
			const fileMode = this.readFileMode(offset);
			const fileMtime = this.readFileMtime(offset);
			const fileUser = this.readFileUser(offset);
			const fileGroup = this.readFileGroup(offset);

			this.fileInfo.push({
				name: fileName,
				type: fileType,
				size: fileSize,
				headerOffset: offset,
				mode: fileMode,
				mtime: fileMtime,
				user: fileUser,
				group: fileGroup,
			});

			offset += 512 + 512 * Math.floor((fileSize + 511) / 512);
		}
	}

	private readString(offset: number, maxSize: number) {
		let size = 0;
		let view = new Uint8Array(this.buffer, offset, maxSize);
		while (size < maxSize && view[size]) {
			size += 1;
		}
		view = new Uint8Array(this.buffer, offset, size);
		return utf8Decode(view);
	}

	private readFileName(offset: number) {
		return this.readString(offset, 100);
	}

	private readFileMode(offset: number) {
		const mode = this.readString(offset + 100, 8);
		return Number.parseInt(mode, 8);
	}

	private readFileMtime(offset: number) {
		const mtime = this.readString(offset + 136, 12);
		return Number.parseInt(mtime, 8);
	}

	private readFileUser(offset: number) {
		return this.readString(offset + 265, 32);
	}

	private readFileGroup(offset: number) {
		return this.readString(offset + 297, 32);
	}

	private readFileType(offset: number) {
		const typeView = new Uint8Array(this.buffer, offset + 156, 1);
		const typeStr = String.fromCharCode(typeView[0]);
		return typeStr;
	}

	private readFileSize(offset: number) {
		// offset = 124, length = 12
		const view = new Uint8Array(this.buffer, offset + 124, 12);
		const sizeStr = utf8Decode(view);
		return Number.parseInt(sizeStr, 8);
	}

	private readFileBlob(offset: number, size: number, mimetype: string) {
		const view = new Uint8Array(this.buffer, offset, size);
		return new Blob([view], { type: mimetype });
	}

	private readTextFile(offset: number, size: number) {
		const view = new Uint8Array(this.buffer, offset, size);
		return utf8Decode(view);
	}

	getTextFile(filename: string) {
		const item = this.fileInfo.find((info) => info.name === filename);
		if (item) {
			return this.readTextFile(item.headerOffset + 512, item.size);
		}
	}

	getFileBlob(filename: string, mimetype = "") {
		const item = this.fileInfo.find((info) => info.name === filename);
		if (item) {
			return this.readFileBlob(item.headerOffset + 512, item.size, mimetype);
		}
	}
}

export class TarWriter {
	private fileData: ITarWriteItem[] = [];
	private _buffer: ArrayBuffer | null = null;

	get buffer() {
		if (!this._buffer) {
			throw new Error("Buffer is not set");
		}
		return this._buffer;
	}

	addFile(
		name: string,
		file: string | ArrayBuffer | Uint8Array | Blob,
		opts?: Partial<ITarWriteOptions>,
	) {
		const data = getArrayBuffer(file);
		const size = (data as ArrayBuffer).byteLength ?? (file as Blob).size;
		const item: ITarWriteItem = {
			name,
			type: TarFileTypeCodes.File,
			data,
			size,
			opts,
		};
		this.fileData.push(item);
	}

	addFolder(name: string, opts?: Partial<ITarWriteOptions>) {
		this.fileData.push({
			name,
			type: TarFileTypeCodes.Dir,
			data: null,
			size: 0,
			opts,
		});
	}

	private createBuffer() {
		const dataSize = this.fileData.reduce(
			(prev, item) => prev + 512 + 512 * Math.floor((item.size + 511) / 512),
			0,
		);
		const bufSize = 10240 * Math.floor((dataSize + 10240 - 1) / 10240);
		this._buffer = new ArrayBuffer(bufSize);
	}

	async write() {
		this.createBuffer();
		const view = new Uint8Array(this.buffer);
		let offset = 0;
		for (const item of this.fileData) {
			// write header
			this.writeFileName(item.name, offset);
			this.writeFileType(item.type, offset);
			this.writeFileSize(item.size, offset);
			this.fillHeader(
				offset,
				item.opts as Partial<ITarWriteOptions>,
				item.type,
			);
			this.writeChecksum(offset);

			// write data
			const data = new Uint8Array((await item.data) as ArrayBuffer);
			view.set(data, offset + 512);
			offset += 512 + 512 * Math.floor((item.size + 511) / 512);
		}
		// Required so it works in the browser and node.
		if (typeof Blob !== "undefined") {
			return new Blob([this.buffer], { type: "application/x-tar" });
		}
		return this.buffer;
	}

	private writeString(str: string, offset: number, size: number) {
		const strView = utf8Encode(str);
		const view = new Uint8Array(this.buffer, offset, size);
		for (let i = 0; i < size; i += 1) {
			view[i] = i < strView.length ? strView[i] : 0;
		}
	}

	private writeFileName(name: string, offset: number) {
		// offset: 0
		this.writeString(name, offset, 100);
	}

	private writeFileType(type: TarFileType, offset: number) {
		// offset: 156
		const typeView = new Uint8Array(this.buffer, offset + 156, 1);
		typeView[0] = type.charCodeAt(0);
	}

	private writeFileSize(size: number, offset: number) {
		// offset: 124
		const sizeStr = size.toString(8).padStart(11, "0");
		this.writeString(sizeStr, offset + 124, 12);
	}

	private writeFileMode(mode: number, offset: number) {
		// offset: 100
		this.writeString(mode.toString(8).padStart(7, "0"), offset + 100, 8);
	}

	private writeFileUid(uid: number, offset: number) {
		// offset: 108
		this.writeString(uid.toString(8).padStart(7, "0"), offset + 108, 8);
	}

	private writeFileGid(gid: number, offset: number) {
		// offset: 116
		this.writeString(gid.toString(8).padStart(7, "0"), offset + 116, 8);
	}

	private writeFileMtime(mtime: number, offset: number) {
		// offset: 136
		this.writeString(mtime.toString(8).padStart(11, "0"), offset + 136, 12);
	}

	private writeFileUser(user: string, offset: number) {
		// offset: 265
		this.writeString(user, offset + 265, 32);
	}

	private writeFileGroup(group: string, offset: number) {
		// offset: 297
		this.writeString(group, offset + 297, 32);
	}

	private writeChecksum(offset: number) {
		// offset: 148
		this.writeString("        ", offset + 148, 8); // first fill with spaces

		// add up header bytes
		const header = new Uint8Array(this.buffer, offset, 512);
		let chksum = 0;
		for (let i = 0; i < 512; i += 1) {
			chksum += header[i];
		}
		this.writeString(chksum.toString(8), offset + 148, 8);
	}

	private fillHeader(
		offset: number,
		opts: Partial<ITarWriteOptions>,
		fileType: TarFileType,
	) {
		const { uid, gid, mode, mtime, user, group } = {
			uid: 1000,
			gid: 1000,
			mode: fileType === TarFileTypeCodes.File ? 0o664 : 0o775,
			mtime: ~~(Date.now() / 1000),
			user: "tarballjs",
			group: "tarballjs",
			...opts,
		};

		this.writeFileMode(mode, offset);
		this.writeFileUid(uid, offset);
		this.writeFileGid(gid, offset);
		this.writeFileMtime(mtime, offset);

		this.writeString("ustar", offset + 257, 6); // magic string
		this.writeString("00", offset + 263, 2); // magic version

		this.writeFileUser(user, offset);
		this.writeFileGroup(group, offset);
	}
}

function getArrayBuffer(file: string | ArrayBuffer | Uint8Array | Blob) {
	if (typeof file === "string") {
		return utf8Encode(file).buffer;
	}
	if (file instanceof ArrayBuffer) {
		return file;
	}
	if (ArrayBuffer.isView(file)) {
		return new Uint8Array(file).buffer;
	}
	return file.arrayBuffer();
}
